Skip to content

fix(acp): follow threads the agent participates in (#2270)#2375

Open
brocoppler wants to merge 3 commits into
block:mainfrom
brocoppler:fix/acp-thread-following
Open

fix(acp): follow threads the agent participates in (#2270)#2375
brocoppler wants to merge 3 commits into
block:mainfrom
brocoppler:fix/acp-thread-following

Conversation

@brocoppler

@brocoppler brocoppler commented Jul 22, 2026

Copy link
Copy Markdown

What this solves

Fixes #2270 (independently re-reported this morning as #2332). A mention-gated agent (SubscribeMode::Mentions, the default) goes deaf in threads it has joined: once it replies, untagged follow-ups like "yes, go ahead" are never seen unless the human re-tags the agent on every message. The gate exists at two levels — the relay REQ's #p clause never delivers the reply, and match_event's mention re-check would drop it anyway.

How it works

Participation opens a thread; inactivity closes it.

  • thread_follow.rs (new): per-channel set of followed NIP-10 thread roots with a sliding inactivity TTL (default 24h, --thread-follow-ttl-secs / BUZZ_ACP_THREAD_FOLLOW_TTL_SECS) and a hard per-channel cap (64, least-recently-active evicted). Participation is recorded at dispatch time: the batch's thread root, or the triggering event id when the agent's reply is about to start a new thread (mirrors resolve_reply_anchor — this is the exact repro case in [Bug] buzz-acp agents go deaf in threads they've joined unless re-mentioned. #2270). Admitted followed-thread events refresh the TTL, so live conversations keep sliding.
  • Relay gate: REQ construction is extracted into build_req_filters(), which emits a second filter clause — same #h/kinds/since, plus #e limited to followed roots — only when the base clause is mention-gated. The mention gate stays intact for everything else; no channel firehose. Subscription updates reuse the existing per-channel sub-id replacement path and replay from last_seen minus skew, so replies landing during the update window are recovered. Resubscribes fire only on thread join/expiry, not per message, staying inside the pacing envelope from fix(acp): honor relay rate limits and pace resubscribes on bad links #2199/fix(acp): pace relay observer frames (6/s + 90/min, zero burst) #2217.
  • Local gate: match_event takes the followed set and admits an unmentioned event only when its thread root is followed. Channel scope, kinds, and evalexpr filters still apply — following relaxes only the mention check.
  • Lifecycle: channel unsubscribe clears its follow state; --no-follow-threads (BUZZ_ACP_NO_FOLLOW_THREADS) restores the previous strict-mention behavior. State is in-memory, matching the harness's existing recovery semantics (after restart, a fresh mention re-joins a thread).

Testing

  • 13 new unit tests: follow-state lifecycle (record/touch/TTL/cap/eviction/dirty-tracking/channel isolation), match_event followed-thread admission (admits followed, ignores unfollowed and top-level, relaxes only the mention gate), and build_req_filters clause construction (base-only, roots-ignored-without-mention-gate, two-clause shape).
  • Full cargo test -p buzz-acp: 583 passed, 0 failed. cargo clippy --all-targets --all-features clean, cargo fmt clean.
  • Verified end-to-end against a live local relay (Claude Code behind claude-code-acp): mention answered in-thread; untagged human follow-up in the followed thread answered (~15s, p tags empty); untagged replies in unfollowed threads silent; agent-authored unmentioned replies suppressed under both owner-only and --respond-to anyone (see review thread for transcripts). Can add these as a buzz-test-client integration test on request.

Follow-up candidates (out of scope here)

🤖 Generated with Claude Code

@brocoppler
brocoppler requested a review from a team as a code owner July 22, 2026 15:29
@brocoppler
brocoppler force-pushed the fix/acp-thread-following branch from 8173991 to 930c0ef Compare July 22, 2026 15:35
@BradGroux

Copy link
Copy Markdown

Blocking evidence against current head 930c0ef33262d6f15772ff9d253e81b3853730e7: the implementation does not preserve #2270's stated anti-loop invariant.

Control-flow evidence

The current head does these three things in sequence:

  1. author_allowed explicitly allows same-owner sibling agents in both OwnerOnly and Allowlist modes.
  2. The inbound path applies that author gate, then passes the channel's followed roots into match_event (lib.rs).
  3. match_event bypasses require_mention whenever the event's root is followed. It does not distinguish a human author from an agent author on that followed-only path.

Once sibling agents A and B have both participated in one thread, an unmentioned reply from B can therefore wake A. A's reply can wake B through the same rule. That is the agent-to-agent loop #2270 explicitly says must remain impossible.

This is not covered by the new tests. They prove that a generic unmentioned event in a followed root is admitted, but do not exercise author identity or two participating agents.

Required correction

Please keep explicit agent mentions working, but reject agent-authored, unmentioned events that are admitted only because their root is followed. If the proposed follow_thread_authors = "humans" | "all" policy is retained, the safe default needs to be humans.

Regression coverage should include:

  • human unmentioned reply in a followed root → admitted;
  • sibling-agent unmentioned reply in that root → dropped;
  • external agent unmentioned reply in RespondTo::Anyone → dropped;
  • explicitly mentioned agent-authored reply → existing configured behavior preserved;
  • two agents following the same root cannot auto-continue each other.

The PR description also states that this has not been exercised against a live relay. Please add the end-to-end relay case before treating #2270/#2332 as resolved: mention agent → agent replies → human sends an untagged thread reply → harness receives exactly one new turn.

Separate timing evidence for successful explicit mentions is now tracked in #2386; this comment is about correctness and loop safety, not model latency.

brocoppler added a commit to brocoppler/buzz that referenced this pull request Jul 22, 2026
Review of block#2375 (BradGroux) identified an agent-to-agent loop the initial
thread-following change permitted: ignore_self blocks self-loops and the
author gate blocks strangers, but same-owner sibling agents pass the gate
by design — so two siblings participating in one thread could wake each
other with unmentioned replies indefinitely, each turn refreshing the
follow TTL.

Followed-thread admissions are now author-aware:

- match_event reports the admission basis (admitted_via_follow) so the
  caller can distinguish follow-bypass admissions from mention matches.
- A follow-only admission from an agent author is suppressed under the
  new --follow-thread-authors policy (BUZZ_ACP_FOLLOW_THREAD_AUTHORS):
  humans (default) admits human authors only; all opts into agent
  authors and accepts the auto-continuation risk. Explicit mentions are
  never affected, and suppression happens before the TTL refresh so
  agent chatter cannot keep a thread alive.
- Agenthood uses the existing NIP-OA heuristic (pool::profile_event_is_agent,
  now pub(crate)) via a cached kind:0 lookup that runs only on the rare
  follow-admitted path; unknown identities classify as human (fail open,
  matching turn_is_human_facing). The security gate remains author_allowed.

Regression coverage per review: human unmentioned reply admitted;
agent-authored unmentioned reply dropped (covers siblings and external
agents under respond-to anyone); mentioned agent-authored replies
unaffected; all-policy opt-in; and the two-agent auto-continuation
scenario cannot start from either side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Broc Oppler <brocoppler@gmail.com>
@brocoppler

Copy link
Copy Markdown
Author

The loop was real: ignore_self blocks self-loops and the author gate blocks strangers, but same-owner siblings pass the gate by design, and the follow bypass in match_event didn't check author kind. Fixed in 6318ba4:

  • match_event reports admission basis (MatchedRule.admitted_via_follow) — follow-bypass admissions are distinguishable from mention matches at the call site.
  • New --follow-thread-authors <humans|all> (BUZZ_ACP_FOLLOW_THREAD_AUTHORS), default humans: follow-only admissions from agent authors are suppressed. Explicit mentions never take this path. Suppression runs before the TTL refresh, so agent chatter can't keep a thread alive.
  • Agenthood classification reuses the existing NIP-OA heuristic (profile_event_is_agent, now pub(crate)) via a cached kind:0 lookup that runs only on the follow-admitted path. It classifies by agenthood, not gate mode, so it covers external agents under RespondTo::Anyone. Unknown/unfetchable identities classify as human (fail open, matching turn_is_human_facing); the security gate remains author_allowed.

Regression coverage per your list: human unmentioned reply in a followed root admitted; agent-authored unmentioned reply dropped (siblings and external-under-Anyone, both via the agenthood classification); explicitly mentioned agent-authored replies unaffected; all as explicit opt-in; two-agent auto-continue cannot start from either side at the decision layer. 589 tests pass, clippy/fmt clean.

Live-relay run (local relay, this branch, Claude Code behind claude-code-acp, second identity carrying the NIP-OA auth-tag shape):

  1. Mention → in-thread reply → untagged human follow-up (p tags []) → exactly one new turn, ~15s. Untagged replies in unfollowed threads: no turn.
  2. Agent-authored unmentioned reply in a followed root → no turn. Under default owner-only the author gate drops it first (its attestation fails signature verification). Under --respond-to anyone — author gate admits everyone, this guard is the only defense — the harness logs followed-thread admission suppressed for agent author and fires no turn.
human:  @Scout final guard test: 7+7? Just the number.      ← dispatched, thread followed
agent2: unmentioned agent reply under respond-to anyone     ← suppressed, no turn
human: @Scout round 3: what is 2+2? …    → agent: "4"
human: thanks - now 3+3? (not tagging)   → agent: "6"   (p tags: [])

Observed turn latency (~15–30s) matches #2386. The PR description's "not yet exercised against a live relay" line is updated. Can add these scenarios as a buzz-test-client integration test if wanted.

brocoppler and others added 2 commits July 22, 2026 14:02
A mention-gated agent (SubscribeMode::Mentions, the default) only ever
received events carrying its #p tag, at two independent gates: the relay
REQ filter and match_event's re-check. Once the agent replied in a
thread, untagged follow-ups in that same thread were never delivered —
the agent went deaf mid-conversation unless re-mentioned on every
message.

This records thread participation at dispatch time and opens both gates
for followed threads only:

- thread_follow.rs: per-channel set of participated NIP-10 thread roots
  with a sliding inactivity TTL (default 24h, --thread-follow-ttl-secs),
  a per-channel cap (64, stalest evicted), and dirty-channel tracking.
  Participation is recorded when a batch is dispatched — the batch's
  thread root, or the triggering event id when the reply starts a new
  thread (mirroring resolve_reply_anchor).
- relay.rs: REQ construction extracted into build_req_filters(), which
  emits a second filter clause (#h + #e limited to followed roots, same
  kinds/since) when the base clause is mention-gated. The mention gate
  stays intact for everything else; re-issued REQs replay from last_seen
  minus skew, so replies landing during the update window are recovered.
- filter.rs: match_event takes the followed-root set and admits an
  unmentioned event whose thread root is followed. Following relaxes
  only the mention gate — channel scope, kinds, and expression filters
  still apply.
- Followed threads expire after the TTL and the REQ clause is narrowed
  again; channel unsubscribe clears its follow state. --no-follow-threads
  restores the previous strict-mention behavior.

Closes block#2270.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Broc Oppler <brocoppler@gmail.com>
Review of block#2375 (BradGroux) identified an agent-to-agent loop the initial
thread-following change permitted: ignore_self blocks self-loops and the
author gate blocks strangers, but same-owner sibling agents pass the gate
by design — so two siblings participating in one thread could wake each
other with unmentioned replies indefinitely, each turn refreshing the
follow TTL.

Followed-thread admissions are now author-aware:

- match_event reports the admission basis (admitted_via_follow) so the
  caller can distinguish follow-bypass admissions from mention matches.
- A follow-only admission from an agent author is suppressed under the
  new --follow-thread-authors policy (BUZZ_ACP_FOLLOW_THREAD_AUTHORS):
  humans (default) admits human authors only; all opts into agent
  authors and accepts the auto-continuation risk. Explicit mentions are
  never affected, and suppression happens before the TTL refresh so
  agent chatter cannot keep a thread alive.
- Agenthood uses the existing NIP-OA heuristic (pool::profile_event_is_agent,
  now pub(crate)) via a cached kind:0 lookup that runs only on the rare
  follow-admitted path; unknown identities classify as human (fail open,
  matching turn_is_human_facing). The security gate remains author_allowed.

Regression coverage per review: human unmentioned reply admitted;
agent-authored unmentioned reply dropped (covers siblings and external
agents under respond-to anyone); mentioned agent-authored replies
unaffected; all-policy opt-in; and the two-agent auto-continuation
scenario cannot start from either side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Broc Oppler <brocoppler@gmail.com>
@brocoppler
brocoppler force-pushed the fix/acp-thread-following branch from 6318ba4 to a72b450 Compare July 22, 2026 21:03
Drives the real buzz-acp binary with a minimal stub ACP agent
(stub-acp-agent, NDJSON JSON-RPC over stdio) against a live relay and
asserts turn dispatch for the four cases from the block#2375 review:
explicit mention fires a turn and follows the thread; an untagged human
reply in the followed thread fires a second turn; an untagged
agent-authored reply (NIP-OA-tagged kind:0, respond-to=anyone so the
followed-thread author policy is the only gate) is suppressed with the
suppression log line asserted; an untagged top-level message stays
gated.

The stub logs each session/prompt to a file and never contacts the
relay: participation is recorded by the harness at dispatch, so turn
counts alone prove admission/suppression. The agent reply path is
out-of-band by design (block#2459 tracks its observability).

#[ignore]-gated like the rest of the e2e suite; requires a running
relay and a built buzz-acp (BUZZ_ACP_BIN override supported).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Broc Oppler <brocoppler@gmail.com>
@brocoppler

Copy link
Copy Markdown
Author

Integration test added in b9700ca: e2e_acp_thread_follow.rs drives the real buzz-acp binary with a stub ACP agent against a live relay and asserts all four review cases (mention → turn, untagged human follow-up → turn, untagged agent-authored reply → suppressed with the log line asserted, untagged top-level → gated). Passes in ~23s locally; #[ignore]-gated like the rest of the e2e suite.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] buzz-acp agents go deaf in threads they've joined unless re-mentioned.

2 participants